Casino 2 - Attack on gorand - TETCTF-2023

(flashback)

Me: Hey, I think this challenge uses a faulty version of cosmos/iavl. Cuz you know, like, according to the go.mod file, it uses v0.19.2, which is not the latest version. And then there is this change log of version v0.19.3 saying it prevent sth sth and then there is this blog page posted around that release saying iavl is not good etc etc. I think this is the vulnerability.

L.T.: I think ndh doesn't want to tease our brain too hard, so he wouldn't come up with sth this complex. Let's crack the random function.

Me: I agree. There are so many words and crazy stuffs in this blog so it's probably isn't true.

L.T.: Yeah...

Me: Yeah...

Well, that turns out to be the intended vulnerability of the challenge. But we're not here to discuss that (that blog still hurts my brain btw :'() because we actually figured out a way to crack the random function 😄 (yey)

I've seen the discussion around the challenge and I saw players talking about brute-forcing with multiple cores to get the seed of Go's PRNG. Well, we actually found a way to predict the result without having to find the seed 😗

Although this approach only gives us the correct output 14 of the time, I still think it is interesting so it would be fun to share it 🙂

TL;DR


⚠️ (Because I want to write a blog about the details of my discovery, including methods to find it, other methods you can go, what's the challenge is all about, etc, ..., this blog will be a very, probably unnecessarily, lengthy one, and if you're not ready to read it, here is a small recap on what's about to come 🙂)⚠️

(now come to think of it I might have put the whole stream of consciousness in here...)


Consider a list of outputs from rand.Intn(n) named R where n is a relatively small number <231. The i-th element Ri will most likely fall into one of these 4 cases:

Ri=Ri607+Ri273modnorRi=Ri607+Ri273+1modnorRi=Ri607+Ri273231modnorRi=Ri607+Ri273231+1modn

and we use one of these 4 equations as our strategy to guess the next output. Each round we bet balance/2 (or some number that is linear to balance, like balance/3 or sth, your pick 😙). This strategy allows our money not to run out too quickly when we guess the wrong number while still guarantees our money growing exponentially if we win. Since win amount is >> lose amount, we will benefit in the long run and hence can reach our target balance.

Table of contents

Challenge description

(if you know all the functionalities of the code files, you may skip this section anyways 🙂 )

This challenge is about a Go Application that emulates some sort of online casino stuffs. You interact with the server by sending some JSON data as input, and expect some text data as output.

You can:

 

 

Analysis

The number of flag bytes revealed to us scales linearly with the number of bytes there are in our balance, so we probably need to have a lot of money to get the full flag. For example, if the flag is 50 bytes long, we need at least 2508=2400 coin units to get all of it, which is a really really really big number comparing to our humble 2023 coins at the beginning.

There are two ways we may want to approach this problem.

The IAVL bug

First one is the IAVL bug. You can see that users' data is stored in the Casino structure, which contains a tree object. This tree data type comes from github.com/cosmos/iavl library.

Upon inspecting the go.mod file, you can see that it uses version v0.19.2, which is nothing surprising until you find out that the real-world IAVL is version v0.19.4 by the time the challenge was released.

So, I began searching for clues, like "iavl v0.19.2 attack", "cosmos iavl v0.19.2 tree vulnerability attack", then I came upon this blog:

which was suspicious, because it released around the time of v0.19.3 according to this change log. " Pass IAVL Proof Verification?" Maybe there's some way we can craft a fake proofData to send to the PrintFlag function for any balance we wish to? Anyways, reading through the blog (not the one I screenshot-ed above, this one, dunno why it's not in Google search anymore 😕 that one's just for showcase that we tried to find stuffs), it seems like whatever bug they found, it was really bad. So I thought to myself: This must be it.

Then I noticed the change in v0.19.3, it seems like the change in the newer version suggests something about the attack we should come up with :)

However, our investigation about the tree stuff ends here, because this is where me and my teammate stopped (you know why 🙂); and also this blog's about attacking the Go's rand stuff 😗 If you want to read more about the IAVL bug, you should visit these two links, provided by a fellow Discord member:

The Go math/rand stuff

The second way you could dig at this challenge is by exploiting the rand.Intn(n) function from math/rand library. Remember, the server creates random numbers in [0,2023) and you can try to guess them in order to gain/or lose money.

We can try to go all random; however, that only guarantees us to get the correct answer in a probability of 12023, and from the way the server rewards us, +2022 * balance for winning and -balance for losing, it seems like we will just be stuck in the same loop forever if we do that.

 


If only somehow you can find a hidden pattern in the way the server generates the numbers, which is just this line of code btw :333

then you can use it to predict future values, keep winning and rocketing yourself to your target balance. And from the title, you already know that it is possible.

The reason this is feasible because the math/rand library "implements pseudo-random number generators" which is "unsuitable for security-sensitive work" according to this link. It also states that "This package's outputs might be easily predictable regardless of how it's seeded." Great! But how predictable is it?

 


A pseudo-random number generator, or PRNG, like this would generate all of their supposed random numbers based on a seed value. If you know the seed, you basically become god and predict all n values in the bet game.

As some players have pointed out, it is possible to recover the seed of Go's PRNG, because:

This means we just need to brute-force every value from 0 to 2321=4294967295, for which, in today's computer standards, can be done within an hour or so in a low-end laptop, 20 minutes if running multiple cores. For every seed, we set seed by rand.Seed(seed) then call rand.Intn(2023) a few times and compare the outputs with the server's n(s), which can be obtained for free by keep betting 0 coins. When we've found and verified the seed, we just need to keep betting with our balance with n = rand.Intn(2023) to get the flag.

While it is very simple to setup the code to do this, an hour, to me, still feels like it's too much. If you code wrong (probably you won't but who knows?), you would've to spend another hour to debug 🙂 Luckily tho, we can find the pattern of rand.Intn(2023) without the seed 😉, thus solving this challenge in a matter of seconds.

Finding pattern in rand.Intn(n)

Graphing the Calls

Staring at a single line of code won't help a thing. So I decided to use the power of ctrl-click-into-a-function-because-it-helps-us-going-back-to-its-definition-and-i-hope-i-could-have-a-better-name-for-this in VSCode and dive into the code hierarchy and here is the progress:


As you can see, we've got pretty much everything up until the Int63() function. To look at the code for that, we need to go to rng.go, which is at the same directory as rand.go (the reason I got this is because my friend found the code for Int63() at https://go.dev/src/math/rand/rng.go, and I was like wait aren't we also have this file in the system too?), and we've got the complete picture:


While the above graph looks kinda crazy (or not, idk...), when we create an abstract map for them, it is actually surprisingly simple if we just focus on the case where n = 2023 or n is a relatively small number to 231 in general.

 

Formualize it

The graph above can be simplified as a formula in Python:

Because taking the last 63 bits then shift 32 bits to the right is equivalent to shift 32 bit to the right and taking the last 31 bits, we can rewrite it like this:

we can see that & ((1<<31) - 1)) is just % (2**31), so:

This implies if we have two Go programs set to the same seed, one outputting 1 number from rand.Intn(n) and the other outputting 1 number from rand.Uint64(), then the above equation holds.

 


... You're probably wondering why I keep mentioning n is small in the code comment. Well, the above statement is not entirely true. It only happens in high probability for small n. For big n <231, rand.Intn(n) may call rand.Uint64() multiple times before outputting a number. If we investigate into the rand.Int31n(n) function,

you can see that there also exists a max variable, and we have to call rand.Int31() repeatedly until we hit a value <= max. If n is small, max would be very close to 231, thus we most likely just need to call rand.Int31() only once for each rand.Intn(n).

 

Oh, but there is an exception if n is a power of 2. If n is 2something, every call to rand.Intn(n) will definitely call rand.Int31() once.

 

More Pattern

Consider R a list of consecutive outputs from rand.Intn(n), S a list of consecutive outputs from rand.Uint64() setting to the same seed starting at the same time, when n is a small number <231, we most likely have:

Ri=(Si32)mod231modn

where Si,Ri is the i-th output of rand.Uint64() and rand.Intn(n).

 

What's great about this formula is that we can now derive a pattern for rand.Intn(n) through the pattern of rand.Uint64(). And it turns out the pattern of rand.Uint64() is very simple. When you look at the code for rand.Uint64(), you can see it get output from rng.vec array while updating it.

You don't need to know how the rng.vec is created to figure out a pattern for rand.Uint64(). Here, you just need to consider it to be a bunch of random numbers. The important thing you'll need to care is that rng.vec:

If you're familiar with the structure of LFSR, or Linear Feedback Shift Register, you would probably recognize this is kinda like that. The terms like tap and feed make more sense, and you can deduce the following relationship between some of the Si(s):

Si=SirngLen+SirngTapmod264orSi=Si607+Si273mod264

(while it is seemingly enough to conclude Si=Si607+Si273 based on the x := rng.vec[rng.feed] + rng.vec[rng.tap] line, however since rng.vec is stored in an int64 array, we have to include themod264 in there.)

 

The tale of adding two numbers

(i think this section is kinda overkill, but whatever -_-)

What happens to the result when we add two b-bit numbers A and B? Well, sometimes, the A+B fits into a b-bit value. In this case,

A+B=A+Bmod2b

The other times, the result overflows, at most 1 bit into the left. In this case,

A+B=(A+Bmod2b)+2b

What is also interesting when we add two numbers, is that if you ignore some of m-lower bits at the end, the addition in the higher bits is still true in some cases. This is essentially saying that,

(A+B)m=(Am)+(Bm)

However, sometimes, since the addition in the lower m-bits results in a carry, we will also get this case:

(A+B)m=(Am)+(Bm)+1

Piecing the puzzle together

(i'm not sure if this reasoning is correct... but I'll try my best)

(at first I was going to use the figures similar to the previous section but I don't know how, so I just stick with the formula, hope this make senses to all of you 🙂)

You're probably know where this is going. Because:

Si=Si607+Si273mod264

... we have:

Si+{0,264}=Si607+Si273

(i dunno if the notation +{a,b} actually represents add a or b or not... But that's what I meant :))

... shift right both sides by 32...

(Si32)+{0,232}=(Si60732)+(Si27332)+{0,1}

... and we reduce both sidesmod231...

(Si32)mod231=(Si60732)+(Si27332)+{0,1}mod231

({0,232} vanishes cuz 0 or 232 equals 0 mod 231)

But this also means:

(Si32)mod231=((Si60732)mod231)+((Si27332)mod231){0,231}+{0,1}

Now the final ingredient, takingmodn on both sides, and voilà!:

Ri=Ri607+Ri273{0,231}+{0,1}modn

Take your balance to the infinity and beyond 🚀

Now we've known how the outputs of rand.Intn(n) are related. We can collect numbers generated by the server into a list R by just betting random numbers in [0,2023). You can bet 0 coin units to prevent yourself from losing money.

After 607 numbers are collected, you choose from one of the 4 possibilities that states either the next output Ri is:

Ri=Ri607+Ri273modnorRi=Ri607+Ri273+1modnorRi=Ri607+Ri273231modnorRi=Ri607+Ri273231+1modn

and bet Ri with that guess.

Because win amount is 2022 times bigger than lose amount, while our possibility of hitting the correct answer is 14, our money will be increasing in the long run. If we bet with some amount that is proportional to balance like balance/2, this allows our money to grow exponentially, makes it easy to hit some crazy big number like 2400 in no time.

(i'm not sure that's optimal or not... but balance/2 works so I don't think too much about that 🙂)

When you get 2400 coin units, you request showBalanceWithProof to get proofData then submit it along with your balance to PrintFlag to grab some flag! (yey)

Appendices

Solve script

Modifying dockerfile

Yeah, building a docker for this challenge sucks, because it keeps yelling at us that there's no go.sum file :< So we decided to fix the Dockerfile, add a compose.yml file so that we can just docker compose it, making our lives easier 🙂 . Here are them 2 files:

Dockerfile (modified)

compose.yml

After starting the container, you can connect to localhost:31339 to play the challenge 😗